Claude/main portfolio footer g1 gdx#74
Conversation
Adds a Portfolio navigation item in the Site section of the backend dashboard, allowing easy access to the portfolio page via iframe. https://claude.ai/code/session_0138bAjho1fWwiRZju3nJFJ3
- Create docs.html with markdown parsing via GitHub API - Fix backend: bypass login (Supabase not configured), link to docs.html - Fix portfolio: repo count shows only non-fork repos - Fix footer links: point to ThePhoenixAgency.github.io https://claude.ai/code/session_0138bAjho1fWwiRZju3nJFJ3
- Create viewer.html to display GitHub repos with template styling - Update portfolio to open project links in viewer instead of new tabs - Add section anchors sidebar in docs.html with sticky navigation - Generate anchor IDs from markdown headings https://claude.ai/code/session_0138bAjho1fWwiRZju3nJFJ3
There was a problem hiding this comment.
Pull request overview
Adds new in-site pages to view GitHub repository content and project documentation, and wires portfolio/backend navigation to these pages.
Changes:
- Added
viewer.htmlto fetch GitHub repo metadata + README and render it in a styled “viewer” page. - Added
docs.htmlto browse markdown files fromdocs/with a sidebar and generated section anchors. - Updated
portfolio.htmlandbackend.htmlnavigation/links to point to the new viewer/docs pages; adjusted some footer links.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
| viewer.html | New repo viewer page that fetches GitHub repo info + README and renders it. |
| docs.html | New documentation browser that fetches markdown docs from GitHub and renders them with anchors. |
| portfolio.html | Routes project links to viewer.html and adjusts GitHub/org stats calculation and footer link. |
| backend.html | Adds Portfolio/Documentation navigation entries and changes session/login behavior. |
| about.html | Updates footer “ThePhoenixAgency” link to external website. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| <a href="javascript:history.back()">Back</a> | ||
| <span class="viewer-title" id="viewerTitle">Loading...</span> |
There was a problem hiding this comment.
Using a javascript: URL in an href (Back link) is discouraged and can be blocked by CSP / security scanners. Prefer a normal link target (e.g., portfolio.html) and/or attach a click handler that calls history.back() with a safe fallback when there is no history.
| html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>'); | ||
|
|
There was a problem hiding this comment.
Markdown links are emitted as <a href="$2" …> from raw markdown without attribute escaping or URL scheme validation. A malicious doc can inject quotes/attributes or javascript: URLs, resulting in XSS when docsContent.innerHTML is set. Use marked + DOMPurify (already used in readme-viewer.html) or implement strict escaping + allowlist URL schemes (http/https) before generating anchors.
| html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>'); | |
| function escapeHtml(str) { | |
| return str.replace(/[&<>"']/g, function (ch) { | |
| switch (ch) { | |
| case '&': return '&'; | |
| case '<': return '<'; | |
| case '>': return '>'; | |
| case '"': return '"'; | |
| case '\'': return '''; | |
| default: return ch; | |
| } | |
| }); | |
| } | |
| function sanitizeUrl(url) { | |
| if (!url) return null; | |
| const trimmed = url.trim(); | |
| // Allow same-page anchors | |
| if (trimmed.startsWith('#')) { | |
| return trimmed; | |
| } | |
| try { | |
| const parsed = new URL(trimmed, window.location.origin); | |
| const protocol = parsed.protocol.toLowerCase(); | |
| if (protocol === 'http:' || protocol === 'https:') { | |
| return parsed.href; | |
| } | |
| } catch (e) { | |
| // Invalid URL, fall through to null | |
| } | |
| return null; | |
| } | |
| html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (match, text, href) => { | |
| const safeUrl = sanitizeUrl(href); | |
| const safeText = escapeHtml(text); | |
| if (!safeUrl) { | |
| // If URL is unsafe or invalid, render just the escaped text | |
| return safeText; | |
| } | |
| return `<a href="${safeUrl}" target="_blank" rel="noopener">${safeText}</a>`; | |
| }); |
| try { | ||
| // Try GitHub API first for rendered markdown | ||
| const apiUrl = `https://api.github.com/repos/${DOCS_CONFIG.owner}/${DOCS_CONFIG.repo}/contents/${DOCS_CONFIG.docsPath}/${filename}`; | ||
|
|
||
| const response = await fetch(apiUrl, { | ||
| headers: { | ||
| 'Accept': 'application/vnd.github.v3.raw' | ||
| } |
There was a problem hiding this comment.
Comment says “Try GitHub API first for rendered markdown”, but the request uses Accept: application/vnd.github.v3.raw which returns raw markdown. Update the comment to match behavior (raw fetch + local render) to avoid confusion when maintaining this code.
| // Check if already logged in - bypass login for now | ||
| function checkSession() { | ||
| const session = localStorage.getItem('supabase_session'); | ||
| if (session) { | ||
| showDashboard(); | ||
| } | ||
| // Always show dashboard (Supabase not configured) | ||
| showDashboard(); | ||
| } |
There was a problem hiding this comment.
checkSession() now always calls showDashboard(), bypassing the login overlay entirely. This makes backend.html effectively unauthenticated even if/when Supabase is later configured, and can expose admin/dashboard UI publicly. Gate this behind an explicit dev flag, or keep the session check and require login when no valid session exists.
|
|
||
| <div class="social-links"> | ||
| <a href="https://github.com/ThePhoenixAgency" target="_blank" class="social-link"> | ||
| <a href="viewer.html?url=https://github.com/ThePhoenixAgency" class="social-link"> |
There was a problem hiding this comment.
The GitHub social link now routes to viewer.html with an org/user URL (https://github.com/ThePhoenixAgency), but viewer.html only supports GitHub repository URLs (owner/repo) and will show “Unsupported URL”. Either keep this link pointing directly to GitHub (as before), or extend viewer.html to support org/user pages (or pass a supported repo URL).
| <a href="viewer.html?url=https://github.com/ThePhoenixAgency" class="social-link"> | |
| <a href="https://github.com/ThePhoenixAgency" target="_blank" class="social-link"> |
| // Fetch repos first (single API call for all data) | ||
| const reposResponse = await fetch(`https://api.github.com/orgs/${PORTFOLIO_CONFIG.githubOrg}/repos?per_page=100`); | ||
| if (!reposResponse.ok) throw new Error('Failed to fetch repos'); | ||
|
|
||
| let repos = await reposResponse.json(); | ||
| if (!Array.isArray(repos)) throw new Error('Invalid response'); | ||
|
|
||
| // Calculate total stars | ||
| // Filter: hide forks FIRST before counting | ||
| if (PORTFOLIO_CONFIG.hideForks) { | ||
| repos = repos.filter(r => !r.fork); | ||
| } | ||
|
|
||
| // Now count repos (only non-forks) | ||
| const reposEl = document.getElementById('totalRepos'); | ||
| if (reposEl) reposEl.textContent = String(repos.length); | ||
|
|
||
| // Calculate total stars (only from non-fork repos) | ||
| const totalStars = repos.reduce((sum, r) => sum + (r.stargazers_count || 0), 0); | ||
| const starsEl = document.getElementById('totalStars'); | ||
| if (starsEl) starsEl.textContent = String(totalStars); |
There was a problem hiding this comment.
Repository count (and derived totals like stars) are computed from the first page of repos only (per_page=100). For orgs/users with >100 repos, the displayed totals will be incorrect. Consider using the org endpoint’s public_repos for the count and either paginate for star totals or clearly label these as “top 100”.
| // Parse markdown to HTML | ||
| function parseMarkdown(md) { | ||
| let html = md; | ||
| html = html.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); | ||
| html = html.replace(/```(\w*)\n([\s\S]*?)```/g, '<pre><code>$2</code></pre>'); | ||
| html = html.replace(/`([^`]+)`/g, '<code>$1</code>'); | ||
| html = html.replace(/^### (.+)$/gm, '<h3>$1</h3>'); | ||
| html = html.replace(/^## (.+)$/gm, '<h2>$1</h2>'); | ||
| html = html.replace(/^# (.+)$/gm, '<h1>$1</h1>'); | ||
| html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>'); | ||
| html = html.replace(/\*(.+?)\*/g, '<em>$1</em>'); | ||
| html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank">$1</a>'); | ||
| html = html.replace(/^> (.+)$/gm, '<blockquote>$1</blockquote>'); |
There was a problem hiding this comment.
Markdown links are converted into <a href="$2" …> using raw URL text from the README. Because the URL isn’t attribute-escaped or validated, a crafted markdown link can inject attributes (quotes) or use javascript: URLs, leading to XSS. Use a real markdown parser + sanitizer (e.g., marked + DOMPurify as used in readme-viewer.html) or strictly validate/escape link URLs before inserting into innerHTML.
| viewerContent.innerHTML = ` | ||
| <h1>${repoData.name}</h1> | ||
| <p>${repoData.description || 'No description'}</p> | ||
| <div class="repo-stats"> | ||
| <span class="repo-stat">Stars: ${repoData.stargazers_count}</span> | ||
| <span class="repo-stat">Forks: ${repoData.forks_count}</span> | ||
| <span class="repo-stat">Language: ${repoData.language || 'N/A'}</span> | ||
| <span class="repo-stat">Updated: ${new Date(repoData.updated_at).toLocaleDateString()}</span> | ||
| </div> | ||
| <hr style="border-color: rgba(255,255,255,0.1); margin: 30px 0;"> | ||
| ${readmeHtml || '<p>No README available.</p>'} | ||
| `; |
There was a problem hiding this comment.
This template assigns viewerContent.innerHTML with unescaped data coming from the GitHub API (repoData.name/description) plus generated README HTML. Since viewer.html accepts arbitrary repo URLs via query params, this is effectively untrusted input and can enable XSS. Prefer building DOM nodes with textContent for API fields, and sanitize any rendered markdown before insertion.
No description provided.